From 3e26373bfeee003d6f1025159a1878e00e36e5d0 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 23 Jun 2026 20:42:04 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=BC=EB=AA=A9=EA=B7=A0=ED=98=95=ED=91=9C?= =?UTF-8?q?=20=EC=A0=84=EB=9E=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/StrategyDslToTa4jAdapter.java | 52 +++++ .../src/components/StrategyEditorPage.tsx | 62 +++++- .../components/strategyEditor/FlowNodes.tsx | 38 +++- .../IchimokuBbPairNodeSettings.tsx | 128 +++++++++++ .../strategyEditor/IndicatorPaletteTab.tsx | 6 + .../components/strategyEditor/PaletteChip.tsx | 4 +- .../strategyEditor/StrategyEditorCanvas.tsx | 33 ++- .../src/hooks/useStrategyEvaluationEditor.ts | 15 +- frontend/src/utils/bollingerConfig.ts | 4 +- frontend/src/utils/chartCustomOverlay.ts | 4 +- frontend/src/utils/ichimokuBbStrategy.ts | 198 ++++++++++++++++++ frontend/src/utils/indicatorLabels.ts | 6 +- frontend/src/utils/indicatorRegistry.ts | 2 +- frontend/src/utils/stableStrategyPairs.ts | 57 ++++- frontend/src/utils/strategyEditorShared.tsx | 38 +++- frontend/src/utils/strategyFlowLayout.ts | 15 +- frontend/src/utils/strategyPaletteStorage.ts | 19 ++ frontend/src/utils/strategyTypes.ts | 24 ++- 18 files changed, 674 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/strategyEditor/IchimokuBbPairNodeSettings.tsx create mode 100644 frontend/src/utils/ichimokuBbStrategy.ts diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 3094c45..be6bae2 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -487,6 +487,10 @@ public class StrategyDslToTa4jAdapter { Map indParams = condNodeParams.isEmpty() ? globalParams : mergeParams(globalParams, condNodeParams); + if ("ICHIMOKU_BB".equals(indType)) { + return buildIchimokuBbRule(series, ctx, cond, condType, candleRangeMode, candleRange); + } + try { Indicator left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod, cond, ctx); Indicator right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod, cond, ctx); @@ -1138,6 +1142,54 @@ public class StrategyDslToTa4jAdapter { return new CrossedUpIndicatorRule(close, priorCloudTop); } + /** + * 일목 후행스팬(현재 종가) vs N봉 전 볼린저밴드 — 상향/하향 돌파·위/아래 유지. + * cond.params.chikouDisplacement (기본 26), rightField: UPPER_BAND | MIDDLE_BAND | LOWER_BAND + */ + private Rule buildIchimokuBbRule( + BarSeries series, + RuleBuildContext ctx, + JsonNode cond, + String condType, + String candleRangeMode, + int candleRange) { + Map> allParams = ctx.indicatorParams(); + Map ichGlobal = allParams != null + ? allParams.getOrDefault("IchimokuCloud", Map.of()) + : Map.of(); + Map bbGlobal = allParams != null + ? allParams.getOrDefault("BollingerBands", Map.of()) + : Map.of(); + Map nodeParams = extractConditionNodeParams(cond); + Map ichParams = nodeParams.isEmpty() ? ichGlobal + : mergeParams(ichGlobal, nodeParams); + Map bbParams = nodeParams.isEmpty() ? bbGlobal + : mergeParams(bbGlobal, nodeParams); + + int d = intP(ichParams, "chikouDisplacement", + ichimokuChikouDisplacement(ichParams)); + String bandField = cond.path("rightField").asText("UPPER_BAND"); + if (!"UPPER_BAND".equals(bandField) && !"MIDDLE_BAND".equals(bandField) && !"LOWER_BAND".equals(bandField)) { + bandField = "UPPER_BAND"; + } + + ClosePriceIndicator close = new ClosePriceIndicator(series); + Indicator bbLine = resolveField(bandField, "BOLLINGER", bbParams, series, -1, -1, cond, ctx); + 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); + default -> { + log.warn("[Adapter] ICHIMOKU_BB 미지원 conditionType: {}", condType); + yield new BooleanRule(false); + } + }; + return applyCandleRangeWindow(core, candleRangeMode, candleRange, condType); + } + /** 종가가 구름 위 */ private Rule buildCloudAboveRule(BarSeries s, Map p) { NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index b9c40eb..544eecc 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -75,6 +75,15 @@ import { inferInflection33FilterLevel, type Inflection33FilterLevel, } from '../utils/inflection33Strategy'; +import { + buildIchimokuBbTree, + isIchimokuBbPaletteValue, + isIchimokuBbPairRoot, + setIchimokuBbPairConfig, + patchIchimokuBbPairInTree, + inferIchimokuBbConfig, + type IchimokuBbPairConfig, +} from '../utils/ichimokuBbStrategy'; import { buildStableStrategyTree, isStableStrategyPaletteValue, @@ -96,6 +105,7 @@ 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, @@ -731,6 +741,36 @@ 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 => ( @@ -1355,17 +1395,20 @@ export default function StrategyEditorPage({ const stochPair = isStochOverboughtPairPaletteValue(item.value); const priceExtremeBreakout = isPriceExtremeBreakoutPairPaletteValue(item.value); const inflection33 = isInflection33PaletteValue(item.value); + const ichimokuBb = isIchimokuBbPaletteValue(item.value); const stableStrategy = isStableStrategyPaletteValue(item.value); - const composite = item.kind === 'composite' && !stochPair && !priceExtremeBreakout && !inflection33 && !stableStrategy; + const composite = item.kind === 'composite' && !stochPair && !priceExtremeBreakout && !inflection33 && !ichimokuBb && !stableStrategy; const newNode = stochPair ? buildStochOverboughtPairTree(item.secondaryIndicator ?? 'CCI', DEF) : priceExtremeBreakout ? buildPriceExtremeBreakoutTree(item.value, DEF) : inflection33 ? buildInflection33Tree(item.value, DEF) - : stableStrategy - ? buildStableStrategyTree(item.value, DEF) - : makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined); + : ichimokuBb + ? buildIchimokuBbTree(item.value, DEF) + : stableStrategy + ? buildStableStrategyTree(item.value, DEF) + : makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined); if (!newNode) return; handleEditorStateChange(prev => { const root = prev.root; @@ -2143,6 +2186,17 @@ 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/FlowNodes.tsx b/frontend/src/components/strategyEditor/FlowNodes.tsx index 920b0b5..b5890fd 100644 --- a/frontend/src/components/strategyEditor/FlowNodes.tsx +++ b/frontend/src/components/strategyEditor/FlowNodes.tsx @@ -20,7 +20,13 @@ import { inflection33DisplayName, inferInflection33FilterLevel, } from '../../utils/inflection33Strategy'; +import { + isIchimokuBbPairRoot, + ichimokuBbDisplayName, + inferIchimokuBbConfig, +} from '../../utils/ichimokuBbStrategy'; import Inflection33PairNodeSettings from './Inflection33PairNodeSettings'; +import IchimokuBbPairNodeSettings from './IchimokuBbPairNodeSettings'; import { isStableStrategyPairRoot, stableStrategyDisplayName, @@ -235,13 +241,14 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }: const isStochPair = isStochOverboughtPairRoot(node); const isPriceExtremePair = isPriceExtremeBreakoutPairRoot(node); const isInflection33Pair = isInflection33PairRoot(node); + const isIchimokuBbPair = isIchimokuBbPairRoot(node); const isStableStrategyPair = isStableStrategyPairRoot(node); const { onDragEnter, onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d); const [settingsOpen, setSettingsOpen] = useState(false); return (
)} + {isIchimokuBbPair && node.ichimokuBbPair && ( +
+ + {ichimokuBbDisplayName(node.ichimokuBbPair.mode, node.ichimokuBbPair.chikouDisplacement)} + + +
+ )} {isPriceExtremePair && node.priceExtremePair && (
@@ -390,6 +419,13 @@ 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/IndicatorPaletteTab.tsx b/frontend/src/components/strategyEditor/IndicatorPaletteTab.tsx index 7094d3f..af691b7 100644 --- a/frontend/src/components/strategyEditor/IndicatorPaletteTab.tsx +++ b/frontend/src/components/strategyEditor/IndicatorPaletteTab.tsx @@ -6,6 +6,7 @@ import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators'; import { isStochOverboughtPairPaletteValue } from '../../utils/stochOverboughtPair'; import { isPriceExtremeBreakoutPairPaletteValue } from '../../utils/priceExtremeBreakoutPair'; import { isInflection33PaletteValue } from '../../utils/inflection33Strategy'; +import { isIchimokuBbPaletteValue } from '../../utils/ichimokuBbStrategy'; import { isStableStrategyPaletteValue, STABLE_STRATEGY_PALETTE_ITEMS } from '../../utils/stableStrategyPairs'; import { getDefaultIndicatorPeriod } from '../../utils/conditionPeriods'; import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage'; @@ -31,6 +32,9 @@ function periodLabel(item: PaletteItem, def: DefType): string { if (item.kind === 'composite' && isInflection33PaletteValue(item.value)) { return `${item.period ?? 33}일`; } + if (item.kind === 'composite' && isIchimokuBbPaletteValue(item.value)) { + return `${item.period ?? 26}봉`; + } if (item.kind === 'composite' && isStableStrategyPaletteValue(item.value)) { const meta = STABLE_STRATEGY_PALETTE_ITEMS.find(i => i.value === item.value); return meta?.periodHint ?? ''; @@ -189,10 +193,12 @@ export default function IndicatorPaletteTab({ && !isStochOverboughtPairPaletteValue(item.value) && !isPriceExtremeBreakoutPairPaletteValue(item.value) && !isInflection33PaletteValue(item.value) + && !isIchimokuBbPaletteValue(item.value) && !isStableStrategyPaletteValue(item.value)} stochPair={isStochOverboughtPairPaletteValue(item.value)} priceExtremeBreakout={isPriceExtremeBreakoutPairPaletteValue(item.value)} inflection33={isInflection33PaletteValue(item.value)} + ichimokuBb={isIchimokuBbPaletteValue(item.value)} stableStrategy={isStableStrategyPaletteValue(item.value)} secondaryIndicator={item.secondaryIndicator} period={periodLabel(item, def)} diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx index 2bea34c..a9f97a5 100644 --- a/frontend/src/components/strategyEditor/PaletteChip.tsx +++ b/frontend/src/components/strategyEditor/PaletteChip.tsx @@ -27,6 +27,7 @@ interface Props { stochPair?: boolean; priceExtremeBreakout?: boolean; inflection33?: boolean; + ichimokuBb?: boolean; stableStrategy?: boolean; secondaryIndicator?: string; selected?: boolean; @@ -36,7 +37,7 @@ interface Props { export default function PaletteChip({ type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod, - composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, stableStrategy = false, secondaryIndicator, selected = false, onSelect, onAdd, + composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, ichimokuBb = false, stableStrategy = false, secondaryIndicator, selected = false, onSelect, onAdd, }: Props) { const buildPayload = (): PaletteDragPayload => ({ type, value, label, @@ -44,6 +45,7 @@ export default function PaletteChip({ stochPair: stochPair || undefined, priceExtremeBreakout: priceExtremeBreakout || undefined, inflection33: inflection33 || undefined, + ichimokuBb: ichimokuBb || undefined, stableStrategy: stableStrategy || undefined, secondaryIndicator, period: periodValue, diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 6ccf878..6ab1f3d 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -57,6 +57,10 @@ import { setInflection33PairFilterLevel, type Inflection33FilterLevel, } from '../../utils/inflection33Strategy'; +import { + setIchimokuBbPairConfig, + type IchimokuBbPairConfig, +} from '../../utils/ichimokuBbStrategy'; import { setStableStrategyPairFilterLevel, type StableStrategyFilterLevel, @@ -772,6 +776,11 @@ function StrategyEditorCanvasInner({ : n.inflection33Pair?.mode === 'sell' ? (gateType === 'OR' ? n.inflection33Pair : undefined) : undefined, + ichimokuBbPair: n.ichimokuBbPair?.mode === 'buy' + ? (gateType === 'AND' ? n.ichimokuBbPair : undefined) + : n.ichimokuBbPair?.mode === 'sell' + ? (gateType === 'OR' ? n.ichimokuBbPair : undefined) + : undefined, stableStrategyPair: n.stableStrategyPair?.mode === 'buy' ? (gateType === 'AND' ? n.stableStrategyPair : undefined) : n.stableStrategyPair?.mode === 'sell' @@ -866,6 +875,27 @@ 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)) { @@ -894,6 +924,7 @@ function StrategyEditorCanvasInner({ onUpdateStochPairSecondary: handleUpdateStochPairSecondary, onUpdatePriceExtremeFilterLevel: handleUpdatePriceExtremeFilterLevel, onUpdateInflection33FilterLevel: handleUpdateInflection33FilterLevel, + onUpdateIchimokuBbConfig: handleUpdateIchimokuBbConfig, onUpdateStableStrategyFilterLevel: handleUpdateStableStrategyFilterLevel, onChangeLogicGateType: handleChangeLogicGateType, onDropTarget: handleDropTarget, @@ -902,7 +933,7 @@ function StrategyEditorCanvasInner({ onStartCandleTypesChange: handleStartCandleTypesChange, onDeleteStart: handleDeleteStart, }), [ - handleDelete, handleUpdateCondition, handleReplaceLogicNode, handleUpdateStochPairSecondary, handleUpdatePriceExtremeFilterLevel, handleUpdateInflection33FilterLevel, handleUpdateStableStrategyFilterLevel, handleChangeLogicGateType, + handleDelete, handleUpdateCondition, handleReplaceLogicNode, handleUpdateStochPairSecondary, handleUpdatePriceExtremeFilterLevel, handleUpdateInflection33FilterLevel, handleUpdateIchimokuBbConfig, handleUpdateStableStrategyFilterLevel, handleChangeLogicGateType, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget, handleStartCandleTypesChange, handleDeleteStart, ]); diff --git a/frontend/src/hooks/useStrategyEvaluationEditor.ts b/frontend/src/hooks/useStrategyEvaluationEditor.ts index 89bac32..9dbfb09 100644 --- a/frontend/src/hooks/useStrategyEvaluationEditor.ts +++ b/frontend/src/hooks/useStrategyEvaluationEditor.ts @@ -43,6 +43,10 @@ import { buildInflection33Tree, isInflection33PaletteValue, } from '../utils/inflection33Strategy'; +import { + buildIchimokuBbTree, + isIchimokuBbPaletteValue, +} from '../utils/ichimokuBbStrategy'; import { buildStableStrategyTree, isStableStrategyPaletteValue, @@ -442,17 +446,20 @@ export function useStrategyEvaluationEditor({ const stochPair = isStochOverboughtPairPaletteValue(item.value); const priceExtremeBreakout = isPriceExtremeBreakoutPairPaletteValue(item.value); const inflection33 = isInflection33PaletteValue(item.value); + const ichimokuBb = isIchimokuBbPaletteValue(item.value); const stableStrategy = isStableStrategyPaletteValue(item.value); - const composite = item.kind === 'composite' && !stochPair && !priceExtremeBreakout && !inflection33 && !stableStrategy; + const composite = item.kind === 'composite' && !stochPair && !priceExtremeBreakout && !inflection33 && !ichimokuBb && !stableStrategy; const newNode = stochPair ? buildStochOverboughtPairTree(item.secondaryIndicator ?? 'CCI', def) : priceExtremeBreakout ? buildPriceExtremeBreakoutTree(item.value, def) : inflection33 ? buildInflection33Tree(item.value, def) - : stableStrategy - ? buildStableStrategyTree(item.value, def) - : makeNode('indicator', item.value, signalTab, def, composite ? { composite: true } : undefined); + : ichimokuBb + ? buildIchimokuBbTree(item.value, def) + : stableStrategy + ? buildStableStrategyTree(item.value, def) + : makeNode('indicator', item.value, signalTab, def, composite ? { composite: true } : undefined); if (!newNode) return; handleEditorStateChange(prev => { const root = prev.root; diff --git a/frontend/src/utils/bollingerConfig.ts b/frontend/src/utils/bollingerConfig.ts index eea3c58..0471494 100644 --- a/frontend/src/utils/bollingerConfig.ts +++ b/frontend/src/utils/bollingerConfig.ts @@ -17,7 +17,7 @@ export const DEFAULT_BB_BAND_BACKGROUND = { color: '#42A5F528', } as const; -const BB_PLOT_TITLES = ['중앙값', '어퍼', '로우어'] as const; +const BB_PLOT_TITLES = ['중앙값', '상한선', '하한선'] as const; const BB_PLOT_IDS = ['plot0', 'plot1', 'plot2'] as const; const BB_DEFAULT_COLORS = ['#FF9800', '#42A5F5', '#42A5F5'] as const; @@ -34,7 +34,7 @@ export function resolveBbBandBackground( }; } -/** 업비트 플롯 정의 병합 (중앙값·어퍼·로우어, 기본 색) */ +/** 업비트 플롯 정의 병합 (중앙값·상한선·하한선, 기본 색) */ export function mergeBbPlots( saved: import('./indicatorRegistry').PlotDef[] | undefined, defaults: import('./indicatorRegistry').PlotDef[], diff --git a/frontend/src/utils/chartCustomOverlay.ts b/frontend/src/utils/chartCustomOverlay.ts index 31d047b..6f9e4ce 100644 --- a/frontend/src/utils/chartCustomOverlay.ts +++ b/frontend/src/utils/chartCustomOverlay.ts @@ -29,8 +29,8 @@ export type ChartCustomOverlaySelection = { export const BB_CUSTOM_PLOT_IDS = ['plot0', 'plot1', 'plot2'] as const; export const BB_CUSTOM_LABELS: Record<(typeof BB_CUSTOM_PLOT_IDS)[number], string> = { plot0: '중앙값', - plot1: '어퍼', - plot2: '로우어', + plot1: '상한선', + plot2: '하한선', }; export function createDefaultChartCustomOverlaySelection(): ChartCustomOverlaySelection { diff --git a/frontend/src/utils/ichimokuBbStrategy.ts b/frontend/src/utils/ichimokuBbStrategy.ts new file mode 100644 index 0000000..e8f11f4 --- /dev/null +++ b/frontend/src/utils/ichimokuBbStrategy.ts @@ -0,0 +1,198 @@ +/** + * 일목 후행스팬 × 볼린저밴드 복합 전략 + * + * 현재 후행스팬(종가) vs N봉(chikouDisplacement) 전 볼린저 상한선·중앙값·하한선 + * — 상향/하향 돌파·위/아래 유지 조건 + */ +import type { ConditionDSL, LogicNode } from './strategyTypes'; +import { initConditionPeriodsInherit, type IndicatorPeriodDef } from './conditionPeriods'; +import { genId } from './strategyNodeIds'; + +export const ICHIMOKU_BB_BUY_VALUE = 'ICHIMOKU_BB_BUY'; +export const ICHIMOKU_BB_SELL_VALUE = 'ICHIMOKU_BB_SELL'; + +export const DEFAULT_CHIKOU_DISPLACEMENT = 26; + +export type IchimokuBbBand = 'UPPER_BAND' | 'MIDDLE_BAND' | 'LOWER_BAND'; +export type IchimokuBbConditionType = 'CROSS_UP' | 'CROSS_DOWN' | 'GT' | 'LT'; + +export interface IchimokuBbPairConfig { + mode: 'buy' | 'sell'; + chikouDisplacement: number; + bbBand: IchimokuBbBand; + conditionType: IchimokuBbConditionType; +} + +export const ICHIMOKU_BB_BAND_OPTIONS: { value: IchimokuBbBand; label: string }[] = [ + { value: 'UPPER_BAND', label: '상한선' }, + { value: 'MIDDLE_BAND', label: '중앙값' }, + { value: 'LOWER_BAND', label: '하한선' }, +]; + +export const ICHIMOKU_BB_CONDITION_OPTIONS: { value: IchimokuBbConditionType; label: string }[] = [ + { value: 'CROSS_UP', label: '상향 돌파' }, + { value: 'CROSS_DOWN', label: '하향 돌파' }, + { value: 'GT', label: '위 유지(>)' }, + { value: 'LT', label: '아래 유지(<)' }, +]; + +const DEFAULT_BUY: Omit = { + chikouDisplacement: DEFAULT_CHIKOU_DISPLACEMENT, + bbBand: 'UPPER_BAND', + conditionType: 'CROSS_UP', +}; + +const DEFAULT_SELL: Omit = { + chikouDisplacement: DEFAULT_CHIKOU_DISPLACEMENT, + bbBand: 'LOWER_BAND', + conditionType: 'CROSS_DOWN', +}; + +export function isIchimokuBbBuyPaletteValue(value: string): boolean { + return value === ICHIMOKU_BB_BUY_VALUE; +} + +export function isIchimokuBbSellPaletteValue(value: string): boolean { + return value === ICHIMOKU_BB_SELL_VALUE; +} + +export function isIchimokuBbPaletteValue(value: string): boolean { + return isIchimokuBbBuyPaletteValue(value) || isIchimokuBbSellPaletteValue(value); +} + +export function isIchimokuBbPairRoot(node: LogicNode): boolean { + return !!node.ichimokuBbPair?.mode && (node.type === 'AND' || node.type === 'OR'); +} + +function bandLabel(band: IchimokuBbBand): string { + return ICHIMOKU_BB_BAND_OPTIONS.find(o => o.value === band)?.label ?? band; +} + +function conditionLabel(ct: IchimokuBbConditionType): string { + return ICHIMOKU_BB_CONDITION_OPTIONS.find(o => o.value === ct)?.label ?? ct; +} + +function makeIchimokuBbCondition( + def: IndicatorPeriodDef, + config: Omit, +): LogicNode { + const base: ConditionDSL = { + indicatorType: 'ICHIMOKU_BB', + conditionType: config.conditionType, + leftField: 'LAGGING_SPAN', + rightField: config.bbBand, + candleRange: 1, + valuePeriodOverride: false, + thresholdOverride: false, + rightPeriodOverride: false, + params: { + chikouDisplacement: config.chikouDisplacement, + }, + }; + return { + id: genId(), + type: 'CONDITION', + condition: initConditionPeriodsInherit('ICHIMOKU_BB', base, def), + }; +} + +function wrapPairRoot( + mode: 'buy' | 'sell', + config: Omit, + def: IndicatorPeriodDef, +): LogicNode { + return { + id: genId(), + type: 'AND', + ichimokuBbPair: { mode, ...config }, + children: [makeIchimokuBbCondition(def, config)], + }; +} + +export function buildIchimokuBbBuyTree( + def: IndicatorPeriodDef, + config: Partial> = {}, +): LogicNode { + return wrapPairRoot('buy', { ...DEFAULT_BUY, ...config }, def); +} + +export function buildIchimokuBbSellTree( + def: IndicatorPeriodDef, + config: Partial> = {}, +): LogicNode { + return wrapPairRoot('sell', { ...DEFAULT_SELL, ...config }, def); +} + +export function buildIchimokuBbTree(value: string, def: IndicatorPeriodDef): LogicNode | null { + if (isIchimokuBbBuyPaletteValue(value)) return buildIchimokuBbBuyTree(def); + if (isIchimokuBbSellPaletteValue(value)) return buildIchimokuBbSellTree(def); + return null; +} + +export function ichimokuBbDisplayName(mode: 'buy' | 'sell', displacement = DEFAULT_CHIKOU_DISPLACEMENT): string { + return mode === 'buy' + ? `일목×BB 매수 (N=${displacement})` + : `일목×BB 매도 (N=${displacement})`; +} + +export function ichimokuBbSummaryText(config: IchimokuBbPairConfig): string { + return `후행스팬 ${config.chikouDisplacement}봉 전 ${bandLabel(config.bbBand)} ${conditionLabel(config.conditionType)}`; +} + +export function ichimokuBbPaletteDesc(value: string): string { + if (isIchimokuBbBuyPaletteValue(value)) { + return `후행스팬(종가)이 ${DEFAULT_CHIKOU_DISPLACEMENT}봉 전 볼린저 상한선 상향 돌파 — 밴드·N·조건 변경 가능`; + } + if (isIchimokuBbSellPaletteValue(value)) { + return `후행스팬(종가)이 ${DEFAULT_CHIKOU_DISPLACEMENT}봉 전 볼린저 하한선 하향 돌파 — 밴드·N·조건 변경 가능`; + } + return ''; +} + +export function inferIchimokuBbConfig(node: LogicNode): IchimokuBbPairConfig { + if (node.ichimokuBbPair) return node.ichimokuBbPair; + + const cond = node.children?.[0]?.condition; + const displacement = Number(cond?.params?.chikouDisplacement) || DEFAULT_CHIKOU_DISPLACEMENT; + const bbBand = (cond?.rightField as IchimokuBbBand) ?? DEFAULT_BUY.bbBand; + const conditionType = (cond?.conditionType as IchimokuBbConditionType) ?? DEFAULT_BUY.conditionType; + return { + mode: 'buy', + chikouDisplacement: displacement, + bbBand, + conditionType, + }; +} + +export function setIchimokuBbPairConfig( + node: LogicNode, + patch: Partial, + def: IndicatorPeriodDef, +): LogicNode { + if (!isIchimokuBbPairRoot(node) || !node.ichimokuBbPair) return node; + const next: IchimokuBbPairConfig = { ...node.ichimokuBbPair, ...patch }; + const rebuilt = next.mode === 'buy' + ? buildIchimokuBbBuyTree(def, next) + : buildIchimokuBbSellTree(def, next); + return { ...rebuilt, id: node.id }; +} + +export function patchIchimokuBbPairInTree( + root: LogicNode | null, + pairNodeId: string, + patch: Partial, + def: IndicatorPeriodDef, +): LogicNode | null { + if (!root) return null; + if (root.id === pairNodeId && isIchimokuBbPairRoot(root)) { + return setIchimokuBbPairConfig(root, patch, def); + } + if (!root.children?.length) return root; + let changed = false; + const children = root.children.map(c => { + const patched = patchIchimokuBbPairInTree(c, pairNodeId, patch, def); + if (patched !== c) changed = true; + return patched ?? c; + }); + return changed ? { ...root, children } : root; +} diff --git a/frontend/src/utils/indicatorLabels.ts b/frontend/src/utils/indicatorLabels.ts index 8eb0784..1dccaff 100644 --- a/frontend/src/utils/indicatorLabels.ts +++ b/frontend/src/utils/indicatorLabels.ts @@ -101,8 +101,10 @@ const PLOT_LABELS: Record = { Middle: { ko: '중간', en: 'Middle' }, Basis: { ko: '기준', en: 'Basis' }, 중앙값: { ko: '중앙값', en: 'Basis' }, - 어퍼: { ko: '어퍼', en: 'Upper' }, - 로우어: { ko: '로우어', en: 'Lower' }, + 어퍼: { ko: '상한선', en: 'Upper' }, + 로우어: { ko: '하한선', en: 'Lower' }, + 상한선: { ko: '상한선', en: 'Upper' }, + 하한선: { ko: '하한선', en: 'Lower' }, Tenkan: { ko: '전환선', en: 'Tenkan' }, Kijun: { ko: '기준선', en: 'Kijun' }, SpanA: { ko: '선행스팬1', en: 'SpanA' }, diff --git a/frontend/src/utils/indicatorRegistry.ts b/frontend/src/utils/indicatorRegistry.ts index 79aaf38..b22a499 100644 --- a/frontend/src/utils/indicatorRegistry.ts +++ b/frontend/src/utils/indicatorRegistry.ts @@ -359,7 +359,7 @@ export const INDICATOR_REGISTRY: IndicatorDef[] = [ { type:'MACross',name:'MA Cross', koreanName:'이동평균교차', shortName:'MACross',category:'Moving Averages', overlay:true, defaultParams:{fastLength:9, slowLength:21, src:'close'},plots:[{id:'plot0',title:'Fast', color:'#2962FF',type:'line',lineWidth:2},{id:'plot1',title:'Slow',color:'#FF6D00',type:'line',lineWidth:2}], description:'이동평균 교차 (골든크로스/데드크로스)' }, // ── Channels & Bands ────────────────────────────────────────────────────── - { type:'BollingerBands', name:'Bollinger Bands', koreanName:'볼린저밴드', shortName:'BB', category:'Channels & Bands', overlay:true, defaultParams:{symbolMode:'main', refSymbol:'', length:20, mult:2, offset:0, src:'close', maType:'SMA'}, plots:[{id:'plot0',title:'중앙값',color:'#FF9800',type:'line',lineWidth:1},{id:'plot1',title:'어퍼',color:'#42A5F5',type:'line',lineWidth:1},{id:'plot2',title:'로우어',color:'#42A5F5',type:'line',lineWidth:1}], description:'볼린저 밴드 (SMA ± σ×곱)' }, + { type:'BollingerBands', name:'Bollinger Bands', koreanName:'볼린저밴드', shortName:'BB', category:'Channels & Bands', overlay:true, defaultParams:{symbolMode:'main', refSymbol:'', length:20, mult:2, offset:0, src:'close', maType:'SMA'}, plots:[{id:'plot0',title:'중앙값',color:'#FF9800',type:'line',lineWidth:1},{id:'plot1',title:'상한선',color:'#42A5F5',type:'line',lineWidth:1},{id:'plot2',title:'하한선',color:'#42A5F5',type:'line',lineWidth:1}], description:'볼린저 밴드 (SMA ± σ×곱)' }, { type:'KeltnerChannels',name:'Keltner Channels', koreanName:'켈트너채널', shortName:'KC', category:'Channels & Bands', overlay:true, defaultParams:{length:20, multiplier:2}, plots:[{id:'plot0',title:'Upper', color:'#7E57C2',type:'line',lineWidth:1},{id:'plot1',title:'Middle',color:'#EF5350',type:'line',lineWidth:1},{id:'plot2',title:'Lower',color:'#7E57C2',type:'line',lineWidth:1}], description:'켈트너 채널' }, { type:'DonchianChannels',name:'Donchian Channels', koreanName:'돈치안채널', shortName:'DC', category:'Channels & Bands', overlay:true, defaultParams:{length:20}, plots:[{id:'plot0',title:'Upper', color:'#4CAF50',type:'line',lineWidth:1},{id:'plot1',title:'Basis',color:'#FFC107',type:'line',lineWidth:1},{id:'plot2',title:'Lower',color:'#4CAF50',type:'line',lineWidth:1}], description:'돈치안 채널' }, { type:'Envelope', name:'Envelope', koreanName:'엔벨로프', shortName:'Env', category:'Channels & Bands', overlay:true, defaultParams:{length:20, percent:0.1, src:'close'}, plots:[{id:'plot0',title:'Upper', color:'#009688',type:'line',lineWidth:1},{id:'plot1',title:'Basis',color:'#607D8B',type:'line',lineWidth:1},{id:'plot2',title:'Lower',color:'#009688',type:'line',lineWidth:1}], description:'엔벨로프 채널' }, diff --git a/frontend/src/utils/stableStrategyPairs.ts b/frontend/src/utils/stableStrategyPairs.ts index 28b4b82..4585cd9 100644 --- a/frontend/src/utils/stableStrategyPairs.ts +++ b/frontend/src/utils/stableStrategyPairs.ts @@ -16,6 +16,7 @@ export type StableStrategyId = | 'MA_TREND' | 'ICHIMOKU_TREND' | 'ICHIMOKU_SANYAKU' + | 'ICHIMOKU_PERFECT' | 'MACD_MOMENTUM' | 'BOLLINGER_RANGE'; @@ -57,6 +58,8 @@ export const ICHIMOKU_TREND_BUY = 'ICHIMOKU_TREND_BUY'; export const ICHIMOKU_TREND_SELL = 'ICHIMOKU_TREND_SELL'; export const ICHIMOKU_SANYAKU_BUY = 'ICHIMOKU_SANYAKU_BUY'; export const ICHIMOKU_SANYAKU_SELL = 'ICHIMOKU_SANYAKU_SELL'; +export const ICHIMOKU_PERFECT_BUY = 'ICHIMOKU_PERFECT_BUY'; +export const ICHIMOKU_PERFECT_SELL = 'ICHIMOKU_PERFECT_SELL'; export const MACD_MOMENTUM_BUY = 'MACD_MOMENTUM_BUY'; export const MACD_MOMENTUM_SELL = 'MACD_MOMENTUM_SELL'; export const BOLLINGER_RANGE_BUY = 'BOLLINGER_RANGE_BUY'; @@ -75,6 +78,8 @@ const VALUE_TO_META: Record기준 + 구름 위 + 양운 + 후행>26봉전 종가', + periodHint: '9 / 26 / 52', + }, + { + value: ICHIMOKU_PERFECT_SELL, label: '일목 5원소 매도', strategyId: 'ICHIMOKU_PERFECT', mode: 'sell', + desc: '괘·주가·구름·후행 4중 AND — 전환<기준 + 구름 아래 + 음운 + 후행<26봉전 종가', + periodHint: '9 / 26 / 52', + }, { value: MACD_MOMENTUM_BUY, label: 'MACD 모멘텀 매수', strategyId: 'MACD_MOMENTUM', mode: 'buy', desc: 'MACD 상향돌파 + EMA60 위 + ADX', @@ -472,6 +487,34 @@ function buildIchimokuSanyakuSell(def: IndicatorPeriodDef, level: StableStrategy return wrapOrRoot(branches, pairMeta('ICHIMOKU_SANYAKU', 'sell', level)); } +/** + * 일목 5원소 입체 매매 — ta4j PerfectIchimokuStrategy 와 동일한 4중 AND 필터. + * 괘(전환/기준) · 주가(구름 위/아래) · 구름(양운/음운) · 후행(26봉전 종가 대비). + */ +function buildIchimokuPerfectBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const nodes: LogicNode[] = [ + makeCondition('ICHIMOKU', 'GT', 'CONVERSION_LINE', 'BASE_LINE', def), + makeCondition('ICHIMOKU', 'ABOVE_CLOUD', 'CLOSE_PRICE', 'NONE', def), + ]; + if (level !== 'relaxed') { + nodes.push(makeCondition('ICHIMOKU', 'SPAN1_GT_SPAN2', 'LEADING_SPAN1', 'LEADING_SPAN2', def)); + nodes.push(makeCondition('ICHIMOKU', 'LAGGING_GT_PRICE', 'LAGGING_SPAN', 'CLOSE_PRICE', def)); + } + return wrapAnd(nodes, pairMeta('ICHIMOKU_PERFECT', 'buy', level)); +} + +function buildIchimokuPerfectSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const nodes: LogicNode[] = [ + makeCondition('ICHIMOKU', 'LT', 'CONVERSION_LINE', 'BASE_LINE', def), + makeCondition('ICHIMOKU', 'BELOW_CLOUD', 'CLOSE_PRICE', 'NONE', def), + ]; + if (level !== 'relaxed') { + nodes.push(makeCondition('ICHIMOKU', 'SPAN1_LT_SPAN2', 'LEADING_SPAN1', 'LEADING_SPAN2', def)); + nodes.push(makeCondition('ICHIMOKU', 'LAGGING_LT_PRICE', 'LAGGING_SPAN', 'CLOSE_PRICE', def)); + } + return wrapAnd(nodes, pairMeta('ICHIMOKU_PERFECT', 'sell', level)); +} + function buildMacdMomentumBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const nodes: LogicNode[] = [ makeCondition('MACD', 'CROSS_UP', 'MACD_LINE', 'SIGNAL_LINE', def), @@ -522,6 +565,7 @@ const BUILDERS: Record', LT: '<', GTE: '≥', LTE: '≤', ABOVE_CLOUD: '구름 위', BELOW_CLOUD: '구름 아래', - SPAN1_GT_SPAN2: '양운(선행1>2)', LAGGING_GT_PRICE: '후행>26봉전 종가', + SPAN1_GT_SPAN2: '양운(선행1>2)', SPAN1_LT_SPAN2: '음운(선행1<2)', + LAGGING_GT_PRICE: '후행>26봉전 종가', LAGGING_LT_PRICE: '후행<26봉전 종가', LAGGING_ABOVE_PRIOR_CLOUD: '후행>26봉전 구름', VOLUME_GT_MA_RATIO: '거래량≥MA×비율', }; const fmt = (n: LogicNode): string => { @@ -595,6 +640,10 @@ export function stableStrategyFilterSummary( else if (level === 'balanced') parts.push(`돌파1회·필터N${w}·(전환>기준 OR 양운)`); else parts.push('돌파1회·종가>전환'); } + if (strategyId === 'ICHIMOKU_PERFECT') { + if (level === 'relaxed') parts.push('괘·주가(구름) 2조건만'); + else parts.push('괘·주가·구름·후행 4중 AND'); + } if (strategyId === 'MACD_MOMENTUM' && level === 'relaxed') parts.push('EMA60·ADX 없음'); if (strategyId === 'MA_TREND' && level === 'relaxed') parts.push('ADX 없음'); return parts.join(', '); @@ -639,6 +688,12 @@ export function inferStableStrategyFilterLevel(node: LogicNode): StableStrategyF if (hasLagging && (hasCloudBreak || hasChikouCross)) return 'balanced'; if (hasCloudBreak || hasChikouCross) return 'relaxed'; } + if (id === 'ICHIMOKU_PERFECT') { + const hasSpan = conds.some(c => c.conditionType === 'SPAN1_GT_SPAN2' || c.conditionType === 'SPAN1_LT_SPAN2'); + const hasLagging = conds.some(c => c.conditionType === 'LAGGING_GT_PRICE' || c.conditionType === 'LAGGING_LT_PRICE'); + if (!hasSpan && !hasLagging) return 'relaxed'; + return 'balanced'; + } if (id === 'MACD_MOMENTUM' && !conds.some(c => c.rightField === emaField(60))) return 'relaxed'; if (id === 'MA_TREND' && !conds.some(c => c.indicatorType === 'ADX')) return 'relaxed'; if (id === 'BOLLINGER_RANGE') { diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 4205bfc..7275bc5 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -44,6 +44,14 @@ import { inferInflection33FilterLevel, inflection33FilterLevelLabel, } from '../utils/inflection33Strategy'; +import { + buildIchimokuBbTree, + isIchimokuBbPairRoot, + isIchimokuBbPaletteValue, + ichimokuBbDisplayName, + ichimokuBbSummaryText, + inferIchimokuBbConfig, +} from '../utils/ichimokuBbStrategy'; import { buildStableStrategyTree, isStableStrategyPairRoot, @@ -704,18 +712,18 @@ function buildFieldOptsForSlot( return slot === 'right' ? [ NONE, - { value: 'UPPER_BAND', label: `상단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, - { value: 'MIDDLE_BAND', label: `중심선(${DEF.bbPeriod}일)` }, - { value: 'LOWER_BAND', label: `하단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, + { value: 'UPPER_BAND', label: `상한선(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, + { value: 'MIDDLE_BAND', label: `중앙값(${DEF.bbPeriod}일)` }, + { value: 'LOWER_BAND', label: `하한선(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, { value: 'CLOSE_PRICE', label: '종가' }, { value: 'OPEN_PRICE', label: '시가' }, { value: 'HIGH_PRICE', label: '고가' }, { value: 'LOW_PRICE', label: '저가' }, ] : [ - { value: 'UPPER_BAND', label: `상단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, - { value: 'MIDDLE_BAND', label: `중심선(${DEF.bbPeriod}일)` }, - { value: 'LOWER_BAND', label: `하단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, + { value: 'UPPER_BAND', label: `상한선(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, + { value: 'MIDDLE_BAND', label: `중앙값(${DEF.bbPeriod}일)` }, + { value: 'LOWER_BAND', label: `하한선(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` }, { value: 'CLOSE_PRICE', label: '종가' }, { value: 'OPEN_PRICE', label: '시가' }, { value: 'HIGH_PRICE', label: '고가' }, @@ -985,6 +993,10 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string const level = inferInflection33FilterLevel(node); return `${inflection33DisplayName(mode, period)} [필터:${inflection33FilterLevelLabel(level)}]\n${inflection33SummaryText(mode, period, level)}`; } + if (isIchimokuBbPairRoot(node) && node.ichimokuBbPair?.mode === 'buy') { + const cfg = inferIchimokuBbConfig(node); + return `${ichimokuBbDisplayName(cfg.mode, cfg.chikouDisplacement)}\n${ichimokuBbSummaryText(cfg)}`; + } if (isPriceExtremeBreakoutPairRoot(node) && node.priceExtremePair) { const { mode, shortPeriod, longPeriod } = node.priceExtremePair; const level = inferPriceExtremeFilterLevel(node); @@ -1004,6 +1016,10 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string const level = inferInflection33FilterLevel(node); return `${inflection33DisplayName(mode, period)} [필터:${inflection33FilterLevelLabel(level)}]\n${inflection33SummaryText(mode, period, level)}`; } + if (isIchimokuBbPairRoot(node) && node.ichimokuBbPair?.mode === 'sell') { + const cfg = inferIchimokuBbConfig(node); + return `${ichimokuBbDisplayName(cfg.mode, cfg.chikouDisplacement)}\n${ichimokuBbSummaryText(cfg)}`; + } if (isStochOverboughtPairRoot(node)) { const sec = node.stochPair!.secondaryIndicatorType; return `${stochPairDisplayName(sec)}\n${stochPairSummaryText(sec)}`; @@ -1527,6 +1543,8 @@ export type MakeNodeOptions = { priceExtremeBreakout?: boolean; /** 33변곡 EMA+채널 복합 */ inflection33?: boolean; + /** 일목 후행×볼린저 복합 */ + ichimokuBb?: boolean; /** 추천 안정형 전략 복합 */ stableStrategy?: boolean; }; @@ -1537,6 +1555,7 @@ export function makeNodeOptionsFromPalette(data: { stochPair?: boolean; priceExtremeBreakout?: boolean; inflection33?: boolean; + ichimokuBb?: boolean; stableStrategy?: boolean; secondaryIndicator?: string; period?: number; @@ -1558,6 +1577,9 @@ export function makeNodeOptionsFromPalette(data: { if (data.inflection33 || (data.value && isInflection33PaletteValue(data.value))) { return { inflection33: true }; } + if (data.ichimokuBb || (data.value && isIchimokuBbPaletteValue(data.value))) { + return { ichimokuBb: true }; + } if (data.stableStrategy || (data.value && isStableStrategyPaletteValue(data.value))) { return { stableStrategy: true }; } @@ -1590,6 +1612,10 @@ export const makeNode = ( const tree = buildInflection33Tree(value, DEF); if (tree) return tree; } + if (options?.ichimokuBb || isIchimokuBbPaletteValue(value)) { + const tree = buildIchimokuBbTree(value, DEF); + if (tree) return tree; + } if (options?.stableStrategy || isStableStrategyPaletteValue(value)) { const tree = buildStableStrategyTree(value, DEF); if (tree) return tree; diff --git a/frontend/src/utils/strategyFlowLayout.ts b/frontend/src/utils/strategyFlowLayout.ts index 231ba26..eec7921 100644 --- a/frontend/src/utils/strategyFlowLayout.ts +++ b/frontend/src/utils/strategyFlowLayout.ts @@ -48,6 +48,14 @@ export type StrategyFlowNodeData = { onUpdatePriceExtremeFilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void; /** 33변곡 EMA+채널 복합 — 필터 강도 변경 */ onUpdateInflection33FilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void; + onUpdateIchimokuBbConfig?: ( + nodeId: string, + patch: Partial<{ + chikouDisplacement: number; + bbBand: 'UPPER_BAND' | 'MIDDLE_BAND' | 'LOWER_BAND'; + conditionType: 'CROSS_UP' | 'CROSS_DOWN' | 'GT' | 'LT'; + }>, + ) => void; /** 추천 안정형 전략 — 필터 강도 변경 */ onUpdateStableStrategyFilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void; /** AND ↔ OR 전환 */ @@ -482,7 +490,7 @@ function layoutTree( signalTab: 'buy' | 'sell', callbacks: Pick< StrategyFlowNodeData, - 'onDelete' | 'onUpdateCondition' | 'onReplaceLogicNode' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType' + 'onDelete' | 'onUpdateCondition' | 'onReplaceLogicNode' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateIchimokuBbConfig' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget' >, dragOverId: string | null, @@ -508,6 +516,7 @@ function layoutTree( onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary, onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel, onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel, + onUpdateIchimokuBbConfig: callbacks.onUpdateIchimokuBbConfig, onUpdateStableStrategyFilterLevel: callbacks.onUpdateStableStrategyFilterLevel, onChangeLogicGateType: callbacks.onChangeLogicGateType, onDropTarget: callbacks.onDropTarget, @@ -638,7 +647,7 @@ function appendOrphanFlowNodes( signalTab: 'buy' | 'sell', callbacks: Pick< StrategyFlowNodeData, - 'onDelete' | 'onUpdateCondition' | 'onReplaceLogicNode' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType' + 'onDelete' | 'onUpdateCondition' | 'onReplaceLogicNode' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateIchimokuBbConfig' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget' >, ): void { @@ -664,6 +673,7 @@ function appendOrphanFlowNodes( onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary, onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel, onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel, + onUpdateIchimokuBbConfig: callbacks.onUpdateIchimokuBbConfig, onUpdateStableStrategyFilterLevel: callbacks.onUpdateStableStrategyFilterLevel, onChangeLogicGateType: callbacks.onChangeLogicGateType, onDropTarget: callbacks.onDropTarget, @@ -692,6 +702,7 @@ export function logicNodeToFlow( | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' + | 'onUpdateIchimokuBbConfig' | 'onUpdateStableStrategyFilterLevel' | 'onDropTarget' | 'onDragOverTarget' diff --git a/frontend/src/utils/strategyPaletteStorage.ts b/frontend/src/utils/strategyPaletteStorage.ts index a846920..9af3e4a 100644 --- a/frontend/src/utils/strategyPaletteStorage.ts +++ b/frontend/src/utils/strategyPaletteStorage.ts @@ -10,6 +10,11 @@ import { INFLECTION_33_SELL_VALUE, inflection33PaletteDesc, } from './inflection33Strategy'; +import { + ICHIMOKU_BB_BUY_VALUE, + ICHIMOKU_BB_SELL_VALUE, + ichimokuBbPaletteDesc, +} from './ichimokuBbStrategy'; import { STABLE_STRATEGY_PALETTE_ITEMS, } from './stableStrategyPairs'; @@ -97,6 +102,20 @@ export const DEFAULT_COMPOSITE_ITEMS: Omit[] = [ builtIn: true, period: 33, }, + { + value: ICHIMOKU_BB_BUY_VALUE, + label: '일목×BB 매수', + desc: ichimokuBbPaletteDesc(ICHIMOKU_BB_BUY_VALUE), + builtIn: true, + period: 26, + }, + { + value: ICHIMOKU_BB_SELL_VALUE, + label: '일목×BB 매도', + desc: ichimokuBbPaletteDesc(ICHIMOKU_BB_SELL_VALUE), + builtIn: true, + period: 26, + }, ...STABLE_STRATEGY_PALETTE_ITEMS.map(i => ({ value: i.value, label: i.label, diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index d0cf18f..ebdd4dd 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -73,6 +73,13 @@ export interface LogicNode { mode: 'buy' | 'sell'; filterLevel?: 'strict' | 'balanced' | 'relaxed'; }; + /** 일목 후행스팬 × 볼린저밴드 복합 */ + ichimokuBbPair?: { + mode: 'buy' | 'sell'; + chikouDisplacement: number; + bbBand: 'UPPER_BAND' | 'MIDDLE_BAND' | 'LOWER_BAND'; + conditionType: 'CROSS_UP' | 'CROSS_DOWN' | 'GT' | 'LT'; + }; } export interface StrategyDSLDto { @@ -130,6 +137,7 @@ export const INDICATOR_CONDITIONS: Record = { 'LAGGING_GT_PRICE', 'LAGGING_LT_PRICE', 'LAGGING_ABOVE_PRIOR_CLOUD', 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD', ], + ICHIMOKU_BB: ['GT', 'LT', 'GTE', 'LTE', 'CROSS_UP', 'CROSS_DOWN'], }; // ── 지표별 leftField 옵션 ─────────────────────────────────────────────────── @@ -167,8 +175,8 @@ export const LEFT_FIELD_OPTIONS: Record = { ], BOLLINGER: [ { value: 'CLOSE_PRICE', label: '종가' }, - { value: 'UPPER', label: '상단밴드' }, { value: 'MIDDLE', label: '중단밴드' }, - { value: 'LOWER', label: '하단밴드' }, + { value: 'UPPER', label: '상한선' }, { value: 'MIDDLE', label: '중앙값' }, + { value: 'LOWER', label: '하한선' }, ], DONCHIAN: [ { value: 'CLOSE_PRICE', label: '종가' }, @@ -189,6 +197,9 @@ export const LEFT_FIELD_OPTIONS: Record = { { value: 'LEADING_SPAN1', label: '선행1' }, { value: 'LEADING_SPAN2', label: '선행2' }, { value: 'LAGGING_SPAN', label: '후행스팬' }, ], + ICHIMOKU_BB: [ + { value: 'LAGGING_SPAN', label: '후행스팬' }, + ], }; export const RIGHT_FIELD_OPTIONS: Record = { @@ -250,8 +261,8 @@ export const RIGHT_FIELD_OPTIONS: Record = { { value: 'CUSTOM', label: '직접 입력' }, ], BOLLINGER: [ - { value: 'UPPER', label: '상단밴드' }, { value: 'MIDDLE', label: '중단밴드' }, - { value: 'LOWER', label: '하단밴드' }, { value: 'CUSTOM', label: '직접 입력' }, + { value: 'UPPER', label: '상한선' }, { value: 'MIDDLE', label: '중앙값' }, + { value: 'LOWER', label: '하한선' }, { value: 'CUSTOM', label: '직접 입력' }, ], DONCHIAN: [ { value: 'DC_UPPER_9', label: '상단(9일 최고가)' }, @@ -282,6 +293,11 @@ export const RIGHT_FIELD_OPTIONS: Record = { { value: 'LEADING_SPAN1', label: '선행1' }, { value: 'LEADING_SPAN2', label: '선행2' }, { value: 'LAGGING_SPAN', label: '후행스팬' }, { value: 'CLOSE_PRICE', label: '종가' }, ], + ICHIMOKU_BB: [ + { value: 'UPPER_BAND', label: '상한선' }, + { value: 'MIDDLE_BAND', label: '중앙값' }, + { value: 'LOWER_BAND', label: '하한선' }, + ], VOLUME: [{ value: 'MA5', label: 'MA(5일)' }, { value: 'MA20', label: 'MA(20일)' }, { value: 'CUSTOM', label: '직접 입력' }], DISPARITY: [{ value: 'NEUTRAL_100', label: '기준(100)' }, { value: 'OVERBOUGHT_105', label: '과매수(105)' }, { value: 'OVERSOLD_95', label: '과매도(95)' }, { value: 'CUSTOM', label: '직접 입력' }], };