From 5d90d1a74ed0bdeadf1dc0791ab5ba67f00a3106 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 23 Jun 2026 21:24:37 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=BC=EB=AA=A9-BB=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/StrategyDslToTa4jAdapter.java | 12 +- .../service/IchimokuBbRuleTest.java | 100 ++++++++++++++ .../src/components/StrategyEditorPage.tsx | 47 ------- .../strategyEditor/ConditionNodeSettings.tsx | 20 +++ .../components/strategyEditor/FlowNodes.tsx | 24 ---- .../IchimokuBbPairNodeSettings.tsx | 128 ------------------ .../strategyEditor/StrategyEditorCanvas.tsx | 36 ++--- .../strategyEditor/StrategyListEditor.tsx | 5 +- frontend/src/utils/conditionPeriods.ts | 1 + frontend/src/utils/ichimokuBbStrategy.ts | 68 +++++++++- frontend/src/utils/strategyEditorShared.tsx | 15 ++ frontend/src/utils/strategyPaletteStorage.ts | 7 + frontend/src/utils/strategyTypes.ts | 2 +- 13 files changed, 226 insertions(+), 239 deletions(-) create mode 100644 backend/src/test/java/com/goldenchart/service/IchimokuBbRuleTest.java delete mode 100644 frontend/src/components/strategyEditor/IchimokuBbPairNodeSettings.tsx diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index be6bae2..d692f99 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -1143,7 +1143,7 @@ public class StrategyDslToTa4jAdapter { } /** - * 일목 후행스팬(현재 종가) vs N봉 전 볼린저밴드 — 상향/하향 돌파·위/아래 유지. + * N봉 전 후행스팬(종가) vs N봉 전 볼린저밴드 — 동일 시점(현재봉 기준 N봉 전) 비교. * cond.params.chikouDisplacement (기본 26), rightField: UPPER_BAND | MIDDLE_BAND | LOWER_BAND */ private Rule buildIchimokuBbRule( @@ -1175,13 +1175,15 @@ public class StrategyDslToTa4jAdapter { ClosePriceIndicator close = new ClosePriceIndicator(series); Indicator bbLine = resolveField(bandField, "BOLLINGER", bbParams, series, -1, -1, cond, ctx); + // 후행스팬 = 해당 봉 종가. N봉 전 시점의 후행스팬·BB를 같은 인덱스(t-N)에서 비교. + Indicator priorLagging = new PreviousValueIndicator(close, d); Indicator priorBb = new PreviousValueIndicator(bbLine, d); Rule core = switch (condType) { - case "CROSS_UP" -> buildCrossUpRule(close, priorBb); - case "CROSS_DOWN" -> buildCrossDownRule(close, priorBb); - case "GT", "GTE" -> new OverIndicatorRule(close, priorBb); - case "LT", "LTE" -> new UnderIndicatorRule(close, priorBb); + case "CROSS_UP" -> buildCrossUpRule(priorLagging, priorBb); + case "CROSS_DOWN" -> buildCrossDownRule(priorLagging, priorBb); + case "GT", "GTE" -> new OverIndicatorRule(priorLagging, priorBb); + case "LT", "LTE" -> new UnderIndicatorRule(priorLagging, priorBb); default -> { log.warn("[Adapter] ICHIMOKU_BB 미지원 conditionType: {}", condType); yield new BooleanRule(false); diff --git a/backend/src/test/java/com/goldenchart/service/IchimokuBbRuleTest.java b/backend/src/test/java/com/goldenchart/service/IchimokuBbRuleTest.java new file mode 100644 index 0000000..c01e64a --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/IchimokuBbRuleTest.java @@ -0,0 +1,100 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.Rule; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.indicators.averages.SMAIndicator; +import org.ta4j.core.indicators.bollinger.BollingerBandsUpperIndicator; +import org.ta4j.core.indicators.helpers.ClosePriceIndicator; +import org.ta4j.core.indicators.helpers.PreviousValueIndicator; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/** + * ICHIMOKU_BB — N봉 전 후행스팬(종가) vs N봉 전 BB 동일 시점 비교. + */ +class IchimokuBbRuleTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private StrategyDslToTa4jAdapter adapter; + private BarSeries series; + + @BeforeEach + void setUp() { + adapter = new StrategyDslToTa4jAdapter(); + series = buildSeries(80); + } + + @Test + void ichimokuBb_gt_usesSameOffsetForLaggingAndBb() throws Exception { + JsonNode cond = MAPPER.readTree(""" + { + "type": "CONDITION", + "condition": { + "indicatorType": "ICHIMOKU_BB", + "conditionType": "GT", + "leftField": "LAGGING_SPAN", + "rightField": "UPPER_BAND", + "candleRange": 1, + "params": { "chikouDisplacement": 5 } + } + } + """); + + int evalIndex = 40; + int d = 5; + + Rule rule = adapter.toRule(cond, series, Map.of()); + + ClosePriceIndicator close = new ClosePriceIndicator(series); + SMAIndicator sma = new SMAIndicator(close, 20); + BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator( + sma, series.numFactory().numOf(2)); + PreviousValueIndicator priorClose = new PreviousValueIndicator(close, d); + PreviousValueIndicator priorUpper = new PreviousValueIndicator(upper, d); + + boolean expectedSameOffset = priorClose.getValue(evalIndex) + .isGreaterThan(priorUpper.getValue(evalIndex)); + boolean wrongCurrentClose = close.getValue(evalIndex) + .isGreaterThan(priorUpper.getValue(evalIndex)); + + assertNotEquals(expectedSameOffset, wrongCurrentClose, + "테스트 시계열에서 현재 종가 vs N봉 전 BB와 N봉 전 종가 vs N봉 전 BB가 달라야 함"); + assertEquals(expectedSameOffset, rule.isSatisfied(evalIndex, null)); + } + + private static BarSeries buildSeries(int bars) { + BarSeries s = new BaseBarSeriesBuilder() + .withName("ichimoku-bb-test") + .withNumFactory(DoubleNumFactory.getInstance()) + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Instant base = Instant.parse("2024-01-01T00:00:00Z"); + Duration period = Duration.ofMinutes(3); + double price = 100.0; + for (int i = 0; i < bars; i++) { + double drift = Math.sin(i * 0.35) * 3.0 + (i > 35 ? (i - 35) * 0.4 : 0); + double c = price + drift; + factory.createBarBuilder(s) + .timePeriod(period) + .endTime(base.plus(period.multipliedBy(i + 1L))) + .openPrice(c).highPrice(c + 1.0).lowPrice(c - 1.0).closePrice(c) + .volume(1000) + .add(); + price = c; + } + return s; + } +} diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 544eecc..6012475 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -78,11 +78,6 @@ import { import { buildIchimokuBbTree, isIchimokuBbPaletteValue, - isIchimokuBbPairRoot, - setIchimokuBbPairConfig, - patchIchimokuBbPairInTree, - inferIchimokuBbConfig, - type IchimokuBbPairConfig, } from '../utils/ichimokuBbStrategy'; import { buildStableStrategyTree, @@ -105,7 +100,6 @@ import { import StochPairNodeSettings from './strategyEditor/StochPairNodeSettings'; import PriceExtremePairNodeSettings from './strategyEditor/PriceExtremePairNodeSettings'; import Inflection33PairNodeSettings from './strategyEditor/Inflection33PairNodeSettings'; -import IchimokuBbPairNodeSettings from './strategyEditor/IchimokuBbPairNodeSettings'; import StableStrategyPairNodeSettings from './strategyEditor/StableStrategyPairNodeSettings'; import { emptySignalFlowLayout, @@ -741,36 +735,6 @@ export default function StrategyEditorPage({ handleOrphansChange, handleRootChange, handleExtraRootsChange, ]); - const applyIchimokuBbConfig = useCallback((pairNodeId: string, patch: Partial) => { - if (currentOrphans.some(o => o.id === pairNodeId)) { - handleOrphansChange(currentOrphans.map(o => ( - o.id === pairNodeId ? setIchimokuBbPairConfig(o, patch, DEF) : o - ))); - return; - } - if (currentRoot) { - const patched = patchIchimokuBbPairInTree(currentRoot, pairNodeId, patch, DEF); - if (patched && patched !== currentRoot) { - handleRootChange(patched); - return; - } - } - for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) { - if (!branch) continue; - const patched = patchIchimokuBbPairInTree(branch, pairNodeId, patch, DEF); - if (patched && patched !== branch) { - handleExtraRootsChange({ - ...(currentLayout.extraRoots ?? {}), - [startId]: patched, - }); - return; - } - } - }, [ - currentRoot, currentOrphans, currentLayout.extraRoots, DEF, - handleOrphansChange, handleRootChange, handleExtraRootsChange, - ]); - const applyStableStrategyFilterLevel = useCallback((pairNodeId: string, filterLevel: StableStrategyFilterLevel) => { if (currentOrphans.some(o => o.id === pairNodeId)) { handleOrphansChange(currentOrphans.map(o => ( @@ -2186,17 +2150,6 @@ export default function StrategyEditorPage({ /> )} - {selectedLogicNode && isIchimokuBbPairRoot(selectedLogicNode) && selectedLogicNode.ichimokuBbPair && ( -
- 일목×볼린저 - applyIchimokuBbConfig(selectedLogicNode.id, patch)} - onClose={() => {}} - /> -
- )} {selectedLogicNode && isStableStrategyPairRoot(selectedLogicNode) && selectedLogicNode.stableStrategyPair && (
추천 전략 필터 diff --git a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx index da46a38..df77eed 100644 --- a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx +++ b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx @@ -26,6 +26,10 @@ import { import { compositeDisplayName, compositeElementLabel, COMPOSITE_INDICATOR_ITEMS, switchCompositeIndicatorType } from '../../utils/compositeIndicators'; import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../../utils/strategyStartNodes'; import ComboNumberInput from './ComboNumberInput'; +import { + getChikouDisplacement, + setChikouDisplacement, +} from '../../utils/ichimokuBbStrategy'; interface Props { condition: ConditionDSL; @@ -154,6 +158,17 @@ export default function ConditionNodeSettings({ onChange={p => onChange(setConditionValuePeriod(condition, p, def))} /> + ) : condition.indicatorType === 'ICHIMOKU_BB' ? ( + ) : null} {showThreshold && chartRef != null && ( <> @@ -181,6 +196,11 @@ export default function ConditionNodeSettings({ {condition.composite && (

{compositeDisplayName(condition.indicatorType)}

)} + {condition.indicatorType === 'ICHIMOKU_BB' && ( +

+ 현재 봉 기준 N봉 전 시점에서 후행스팬(종가)과 볼린저 밴드를 같은 시각에 비교합니다. +

+ )}
); } diff --git a/frontend/src/components/strategyEditor/FlowNodes.tsx b/frontend/src/components/strategyEditor/FlowNodes.tsx index b5890fd..6279c13 100644 --- a/frontend/src/components/strategyEditor/FlowNodes.tsx +++ b/frontend/src/components/strategyEditor/FlowNodes.tsx @@ -23,10 +23,8 @@ import { import { isIchimokuBbPairRoot, ichimokuBbDisplayName, - inferIchimokuBbConfig, } from '../../utils/ichimokuBbStrategy'; import Inflection33PairNodeSettings from './Inflection33PairNodeSettings'; -import IchimokuBbPairNodeSettings from './IchimokuBbPairNodeSettings'; import { isStableStrategyPairRoot, stableStrategyDisplayName, @@ -324,21 +322,6 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }: {ichimokuBbDisplayName(node.ichimokuBbPair.mode, node.ichimokuBbPair.chikouDisplacement)} - )} {isPriceExtremePair && node.priceExtremePair && ( @@ -419,13 +402,6 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }: onClose={() => setSettingsOpen(false)} /> )} - {settingsOpen && isIchimokuBbPair && node.ichimokuBbPair && ( - d.onUpdateIchimokuBbConfig?.(id, patch)} - onClose={() => setSettingsOpen(false)} - /> - )} {settingsOpen && isPriceExtremePair && node.priceExtremePair && ( ) => void; - onClose: () => void; - popoverClassName?: string; - variant?: 'popover' | 'inline'; -} - -export default function IchimokuBbPairNodeSettings({ - config, - onChange, - onClose, - popoverClassName = 'se-flow-settings-pop', - variant = 'popover', -}: Props) { - const ref = useRef(null); - - useEffect(() => { - if (variant === 'inline') return; - const onDoc = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) onClose(); - }; - document.addEventListener('mousedown', onDoc); - return () => document.removeEventListener('mousedown', onDoc); - }, [onClose, variant]); - - const body = ( - <> - - - - - - - - -

- 현재 후행스팬(종가)과 N봉 전 시점의 볼린저 밴드를 비교합니다. -

-

- {ichimokuBbSummaryText(config)} -

- - ); - - if (variant === 'inline') { - return ( -
e.stopPropagation()}> - {body} -
- ); - } - - return ( -
e.stopPropagation()} - > -
- 일목×볼린저 - -
- {body} -
- ); -} diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 6ab1f3d..88add43 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -58,8 +58,7 @@ import { type Inflection33FilterLevel, } from '../../utils/inflection33Strategy'; import { - setIchimokuBbPairConfig, - type IchimokuBbPairConfig, + syncIchimokuBbPairsInTree, } from '../../utils/ichimokuBbStrategy'; import { setStableStrategyPairFilterLevel, @@ -722,21 +721,24 @@ function StrategyEditorCanvasInner({ const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => { const next = normalizeConditionForPersistence(condition); + const patchTree = (tree: LogicNode) => syncIchimokuBbPairsInTree( + updateNode(tree, id, n => ({ ...n, condition: next })), + )!; if (isOrphanNode(orphans, id)) { onOrphansChange(orphans.map(o => ( - o.id === id ? { ...o, condition: next } : o + o.id === id ? patchTree(o) : o ))); return; } if (root && findNodeInTree(root, id)) { - onChange(updateNode(root, id, n => ({ ...n, condition: next }))); + onChange(patchTree(root)); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (branch && findNodeInTree(branch, id)) { onExtraRootsChange?.({ ...extraRoots, - [sid]: updateNode(branch, id, n => ({ ...n, condition: next })), + [sid]: patchTree(branch), }); return; } @@ -875,27 +877,6 @@ function StrategyEditorCanvasInner({ } }, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]); - const handleUpdateIchimokuBbConfig = useCallback((id: string, patch: Partial) => { - const patchPair = (n: LogicNode) => setIchimokuBbPairConfig(n, patch, def); - if (isOrphanNode(orphans, id)) { - onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o))); - return; - } - if (root && findNodeInTree(root, id)) { - onChange(updateNode(root, id, patchPair)); - return; - } - for (const [sid, branch] of Object.entries(extraRoots)) { - if (branch && findNodeInTree(branch, id)) { - onExtraRootsChange?.({ - ...extraRoots, - [sid]: updateNode(branch, id, patchPair), - }); - return; - } - } - }, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]); - const handleUpdateStableStrategyFilterLevel = useCallback((id: string, filterLevel: StableStrategyFilterLevel) => { const patchPair = (n: LogicNode) => setStableStrategyPairFilterLevel(n, filterLevel, def); if (isOrphanNode(orphans, id)) { @@ -924,7 +905,6 @@ function StrategyEditorCanvasInner({ onUpdateStochPairSecondary: handleUpdateStochPairSecondary, onUpdatePriceExtremeFilterLevel: handleUpdatePriceExtremeFilterLevel, onUpdateInflection33FilterLevel: handleUpdateInflection33FilterLevel, - onUpdateIchimokuBbConfig: handleUpdateIchimokuBbConfig, onUpdateStableStrategyFilterLevel: handleUpdateStableStrategyFilterLevel, onChangeLogicGateType: handleChangeLogicGateType, onDropTarget: handleDropTarget, @@ -933,7 +913,7 @@ function StrategyEditorCanvasInner({ onStartCandleTypesChange: handleStartCandleTypesChange, onDeleteStart: handleDeleteStart, }), [ - handleDelete, handleUpdateCondition, handleReplaceLogicNode, handleUpdateStochPairSecondary, handleUpdatePriceExtremeFilterLevel, handleUpdateInflection33FilterLevel, handleUpdateIchimokuBbConfig, handleUpdateStableStrategyFilterLevel, handleChangeLogicGateType, + handleDelete, handleUpdateCondition, handleReplaceLogicNode, handleUpdateStochPairSecondary, handleUpdatePriceExtremeFilterLevel, handleUpdateInflection33FilterLevel, handleUpdateStableStrategyFilterLevel, handleChangeLogicGateType, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget, handleStartCandleTypesChange, handleDeleteStart, ]); diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx index 1f195dc..1d683c4 100644 --- a/frontend/src/components/strategyEditor/StrategyListEditor.tsx +++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx @@ -19,6 +19,7 @@ import { setStochPairSecondary, stochPairDisplayName, } from '../../utils/stochOverboughtPair'; +import { syncIchimokuBbPairsInTree } from '../../utils/ichimokuBbStrategy'; import ConditionNodeSettings from './ConditionNodeSettings'; import ConditionPresetPicker from './ConditionPresetPicker'; import StochPairNodeSettings from './StochPairNodeSettings'; @@ -101,7 +102,9 @@ const TreeNodeComp: React.FC = ({ : 'NOT (반대)'; const handleCondChange = (c: NonNullable) => { - onUpdate(prev => updateNode(prev, node.id, n => ({ ...n, condition: c }))); + onUpdate(prev => syncIchimokuBbPairsInTree( + updateNode(prev, node.id, n => ({ ...n, condition: c })), + )!); }; const stochPairRoot = stochPairForest && node.condition diff --git a/frontend/src/utils/conditionPeriods.ts b/frontend/src/utils/conditionPeriods.ts index b4e7b7a..33187ec 100644 --- a/frontend/src/utils/conditionPeriods.ts +++ b/frontend/src/utils/conditionPeriods.ts @@ -446,6 +446,7 @@ export function initConditionPeriods( export function hasNodeSettings(cond: ConditionDSL): boolean { return usesValuePeriodField(cond) || hasEditableThreshold(cond) + || cond.indicatorType === 'ICHIMOKU_BB' || !!cond.composite; } diff --git a/frontend/src/utils/ichimokuBbStrategy.ts b/frontend/src/utils/ichimokuBbStrategy.ts index e8f11f4..8e588e3 100644 --- a/frontend/src/utils/ichimokuBbStrategy.ts +++ b/frontend/src/utils/ichimokuBbStrategy.ts @@ -1,8 +1,8 @@ /** * 일목 후행스팬 × 볼린저밴드 복합 전략 * - * 현재 후행스팬(종가) vs N봉(chikouDisplacement) 전 볼린저 상한선·중앙값·하한선 - * — 상향/하향 돌파·위/아래 유지 조건 + * 현재 봉 기준 N봉(chikouDisplacement) 전 시점에서 + * 후행스팬(종가) vs 볼린저 상한선·중앙값·하한선 — 상·하향 돌파·위/아래 유지 */ import type { ConditionDSL, LogicNode } from './strategyTypes'; import { initConditionPeriodsInherit, type IndicatorPeriodDef } from './conditionPeriods'; @@ -136,15 +136,16 @@ export function ichimokuBbDisplayName(mode: 'buy' | 'sell', displacement = DEFAU } export function ichimokuBbSummaryText(config: IchimokuBbPairConfig): string { - return `후행스팬 ${config.chikouDisplacement}봉 전 ${bandLabel(config.bbBand)} ${conditionLabel(config.conditionType)}`; + const n = config.chikouDisplacement; + return `${n}봉 전 후행스팬(종가) — ${n}봉 전 ${bandLabel(config.bbBand)} ${conditionLabel(config.conditionType)}`; } export function ichimokuBbPaletteDesc(value: string): string { if (isIchimokuBbBuyPaletteValue(value)) { - return `후행스팬(종가)이 ${DEFAULT_CHIKOU_DISPLACEMENT}봉 전 볼린저 상한선 상향 돌파 — 밴드·N·조건 변경 가능`; + return `${DEFAULT_CHIKOU_DISPLACEMENT}봉 전 후행스팬(종가)이 ${DEFAULT_CHIKOU_DISPLACEMENT}봉 전 볼린저 상한선 상향 돌파 — 밴드·N·조건 변경 가능`; } if (isIchimokuBbSellPaletteValue(value)) { - return `후행스팬(종가)이 ${DEFAULT_CHIKOU_DISPLACEMENT}봉 전 볼린저 하한선 하향 돌파 — 밴드·N·조건 변경 가능`; + return `${DEFAULT_CHIKOU_DISPLACEMENT}봉 전 후행스팬(종가)이 ${DEFAULT_CHIKOU_DISPLACEMENT}봉 전 볼린저 하한선 하향 돌파 — 밴드·N·조건 변경 가능`; } return ''; } @@ -196,3 +197,60 @@ export function patchIchimokuBbPairInTree( }); return changed ? { ...root, children } : root; } + +export function getChikouDisplacement(cond: ConditionDSL): number { + const n = Number(cond.params?.chikouDisplacement); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_CHIKOU_DISPLACEMENT; +} + +export function setChikouDisplacement(cond: ConditionDSL, displacement: number): ConditionDSL { + const n = Math.max(1, Math.round(displacement)); + return { + ...cond, + params: { ...(cond.params ?? {}), chikouDisplacement: n }, + }; +} + +export function ichimokuBbPairMetaFromCondition( + mode: 'buy' | 'sell', + cond: ConditionDSL, +): Omit { + const defaults = mode === 'buy' ? DEFAULT_BUY : DEFAULT_SELL; + return { + chikouDisplacement: getChikouDisplacement(cond), + bbBand: (cond.rightField as IchimokuBbBand) ?? defaults.bbBand, + conditionType: (cond.conditionType as IchimokuBbConditionType) ?? defaults.conditionType, + }; +} + +/** 자식 CONDITION 변경 시 게이트 ichimokuBbPair 메타 동기화 */ +export function syncIchimokuBbPairFromChild(node: LogicNode): LogicNode { + if (!isIchimokuBbPairRoot(node) || !node.ichimokuBbPair) return node; + const cond = node.children?.[0]?.condition; + if (!cond || cond.indicatorType !== 'ICHIMOKU_BB') return node; + const nextMeta: IchimokuBbPairConfig = { + mode: node.ichimokuBbPair.mode, + ...ichimokuBbPairMetaFromCondition(node.ichimokuBbPair.mode, cond), + }; + const prev = node.ichimokuBbPair; + if ( + prev.chikouDisplacement === nextMeta.chikouDisplacement + && prev.bbBand === nextMeta.bbBand + && prev.conditionType === nextMeta.conditionType + ) { + return node; + } + return { ...node, ichimokuBbPair: nextMeta }; +} + +export function syncIchimokuBbPairsInTree(root: LogicNode | null): LogicNode | null { + if (!root) return null; + let node = root; + if (node.children?.length) { + const children = node.children.map(c => syncIchimokuBbPairsInTree(c)!); + if (children.some((c, i) => c !== node.children![i])) { + node = { ...node, children }; + } + } + return syncIchimokuBbPairFromChild(node); +} diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 7275bc5..3ace039 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -51,6 +51,7 @@ import { ichimokuBbDisplayName, ichimokuBbSummaryText, inferIchimokuBbConfig, + getChikouDisplacement, } from '../utils/ichimokuBbStrategy'; import { buildStableStrategyTree, @@ -782,6 +783,17 @@ function buildFieldOptsForSlot( ]; return slot === 'right' ? [NONE, ...lines] : lines; } + case 'ICHIMOKU_BB': { + const chikouN = getChikouDisplacement(cond ?? { indicatorType: 'ICHIMOKU_BB' } as ConditionDSL); + if (slot === 'left') { + return [{ value: 'LAGGING_SPAN', label: `${chikouN}봉 전 후행스팬(종가)` }]; + } + return [ + { value: 'UPPER_BAND', label: `${chikouN}봉 전 상한선(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, + { value: 'MIDDLE_BAND', label: `${chikouN}봉 전 중앙값(${DEF.bbPeriod}일)` }, + { value: 'LOWER_BAND', label: `${chikouN}봉 전 하한선(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, + ]; + } default: return slot === 'right' ? [NONE] : []; } @@ -839,6 +851,9 @@ const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: D NEW_HIGH: { l:'CLOSE_PRICE', r: nhPriorField(9) }, NEW_LOW: { l:'CLOSE_PRICE', r: nlPriorField(9) }, ICHIMOKU: { l:'CONVERSION_LINE', r:'BASE_LINE' }, + ICHIMOKU_BB: signalType === 'buy' + ? { l: 'LAGGING_SPAN', r: 'UPPER_BAND' } + : { l: 'LAGGING_SPAN', r: 'LOWER_BAND' }, }; return map[ind] ?? { l:'NONE', r:'NONE' }; }; diff --git a/frontend/src/utils/strategyPaletteStorage.ts b/frontend/src/utils/strategyPaletteStorage.ts index 9af3e4a..941e48f 100644 --- a/frontend/src/utils/strategyPaletteStorage.ts +++ b/frontend/src/utils/strategyPaletteStorage.ts @@ -231,7 +231,14 @@ export function resetPaletteItems(kind: PaletteItemKind): PaletteItem[] { /** 전략편집기·전략빌더에 표시할 지표명 (우측 팔레트 label 우선) */ const STRATEGY_INDICATOR_TYPE_ALIASES: Record = {}; +const STATIC_INDICATOR_DISPLAY_NAMES: Record = { + ICHIMOKU_BB: '일목×BB', +}; + export function getStrategyIndicatorDisplayName(indicatorType: string): string { + if (STATIC_INDICATOR_DISPLAY_NAMES[indicatorType]) { + return STATIC_INDICATOR_DISPLAY_NAMES[indicatorType]; + } const resolved = STRATEGY_INDICATOR_TYPE_ALIASES[indicatorType] ?? indicatorType; for (const kind of ['auxiliary', 'composite'] as const) { diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index ebdd4dd..b6baec6 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -198,7 +198,7 @@ export const LEFT_FIELD_OPTIONS: Record = { { value: 'LAGGING_SPAN', label: '후행스팬' }, ], ICHIMOKU_BB: [ - { value: 'LAGGING_SPAN', label: '후행스팬' }, + { value: 'LAGGING_SPAN', label: 'N봉 전 후행스팬(종가)' }, ], };